/*******************************************************************************
 * This file is part of Crunch Network.
 *
 * Crunch Network is free software: you can redistribute it and/or modify it under the
 * terms of the GNU Lesser General Public License as published by the Free Software
 * Foundation, either version 3 of the License, or (at your option) any later version.
 *
 * Crunch Network is distributed in the hope that it will be useful, but WITHOUT ANY
 * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A
 * PARTICULAR PURPOSE.  See the GNU Lesser General Public License for more details.
 *
 * You should have received a copy of the GNU Lesser General Public License along with
 * Crunch Network.  If not, see <http://www.gnu.org/licenses/>.
 ******************************************************************************/

package com.crunch.network;

import java.io.IOException;
import java.net.Inet4Address;
import java.net.InetAddress;
import java.net.InetSocketAddress;
import java.net.SocketAddress;
import java.nio.ByteBuffer;
import java.nio.channels.DatagramChannel;

/**
 * A {@code NetworkSocket} instance encapsulates a socket on the network.
 *
 * TODO: use a SocketFactory to allow a user-provided socket class to make it easier to run tests
 */
public class NetworkSocket {
	/**
	 * Constructs a {@code NetworkSocket}. The socket is initially closed.
	 */
	public NetworkSocket() {
		socket = null;
	}

	/**
	 * Attempts to opens the socket on the given port. An open socket can receive data sent to its port.
	 *
	 * @param   port the port on which the socket should be opened, in the range 0-65535.
	 * @throws  IllegalArgumentException if {@code port} is not in the range 0-65535.
	 * @throws  IllegalStateException if the socket is already open.
	 * @throws  IOException if the socket failed to be opened.
	 */
	public void open(int port)
			throws IOException {
		if (port < 0 || port > 65535) {
			throw new IllegalArgumentException("Port " + port + " out of range");
		}

		if (isOpen()) {
			throw new IllegalStateException("Socket is already open");
		}

		socket = DatagramChannel.open();
		try {
			socket.configureBlocking(false);
		} catch (IOException e) {
			socket.close();
			throw e;
		}
	}

	/**
	 * Closes the socket. If the socket is already closed no action is taken.
	 */
	public void close() {
		if (!isOpen()) {
			return;
		}

		try {
			socket.close();
		} catch (IOException e) {}
	}

	/**
	 * Returns whether the socket is open.
	 *
	 * @return  whether the socket is open.
	 */
	public boolean isOpen() {
		return socket.isOpen();
	}

	/**
	 * Sends data to the address provided.
	 *
	 * @param   destAddr the address to send data to.
	 * @param   data the buffer containing the data to send.
	 * @throws  IOException if the socket is closed or the data failed to be sent. Note that the lack of an
	 *          {@code IOException} does not necessarily mean that the data was successfully received.
	 */
	public void send(NetworkAddress destAddr, ByteBuffer data)
			throws IOException {
		InetAddress ipAddress = Inet4Address.getByAddress(new byte[] {
				(byte) destAddr.getAddressByte(0),
				(byte) destAddr.getAddressByte(1),
				(byte) destAddr.getAddressByte(2),
				(byte) destAddr.getAddressByte(3)
		});
		SocketAddress address = new InetSocketAddress(ipAddress, destAddr.getPort());
		socket.send(data, address);
	}

	/**
	 * Receives data on the socket's port if there is any data available. If no data is available to receive,
	 * {@code null} is returned. Otherwise, the data is written into the {@code data} buffer provided and the address
	 * from which the data was received is returned in the form of a {@link NetworkAddress}. Data received with a
	 * length greater than {@code maxLength} is silently truncated. The actual length of the data received can be
	 * deduced by examining {@code data} before and after calling the method. If {@code data} doesn't have sufficient
	 * space to store the received data, the appropriate xception will be thrown when attempting to write to the
	 * buffer.
	 *
	 * @param   data the buffer in which to store received data. {@code data} must have a backing array.
	 * @return  the address from which data was received, or {@code null}.
	 * @throws  IllegalArgumentException if {@code data} doesn't have at least {@code maxLength} bytes remaining.
	 * @throws  IOException if the socket is closed or if an error occurs receiving the data.
	 */
	public NetworkAddress receive(ByteBuffer data, int maxLength)
			throws IOException {
		if (data.remaining() < maxLength) {
			throw new IllegalArgumentException("Buffer doesn't have enough space");
		}

		// receive the data
		// packets larger than maxLength are truncated
		SocketAddress srcAddr = socket.receive(data);
		if (srcAddr == null) {
			return null;
		} else {
			if (!(srcAddr instanceof InetSocketAddress)) {
				return null;
			}
			InetSocketAddress inetSrcAddr = (InetSocketAddress) srcAddr;
			InetAddress ipAddress = inetSrcAddr.getAddress();
			if (!(ipAddress instanceof Inet4Address)) {
				return null;
			}
			Inet4Address ip4Address = (Inet4Address) ipAddress;
			byte[] ipBytes = ip4Address.getAddress();

			return new NetworkAddress(
					(int) ipBytes[0] & 0xFF,
					(int) ipBytes[1] & 0xFF,
					(int) ipBytes[2] & 0xFF,
					(int) ipBytes[3] & 0xFF,
					inetSrcAddr.getPort());
		}
	}

	// the underlying socket
	DatagramChannel socket;
}
